實戰篇從幾乎所有系統的第一道門開始。
登入這題其實是三題:登入功能本身怎麼測、驗證碼和 OTP 怎麼辦、其他測試怎麼避免每次重新登入。第三題最常被忽略,卻決定整套測試跑得快不快、穩不穩。
Playwright 官方那個 TodoMVC 練習站幫不上忙,它沒有登入。這篇改用另一個有登入的公開練習站,你不必等公司環境就能把整套跑完一次,最後再照對照表換成自家系統。每一段結尾都有可以自己驗收的檢查點。
一、先測登入本身
這篇要用一個真的連得上去的公開練習站,讓你不必等公司環境就能把整套做完。全部做完之後,再照第四節最後的對照表換成你們自己的系統。
練習站:https://the-internet.herokuapp.com
帳號 tomsmith,密碼 SuperSecretPassword!(這是站上公開寫的,不是祕密)。登入頁在 /login,登入成功之後會轉到 /secure。它沒有驗證碼,所以第二節那題可以先跳過。
它是 Dave Haeffner 做的公開練習站,QA 圈用了十幾年。要留意兩件事:它是第三方站台,不保證永遠在;Playwright 官方也建議不要拿第三方網站當長期測試目標。這裡純粹是拿來練工具。
登入作為一個功能,測法就是前面幾篇的總複習。給 Claude Code 的指令,把第 5 篇的四要素備齊:前置狀態、操作步驟、預期結果、驗證點。
你可以這樣對 Claude Code 說
幫我寫登入功能的測試,檔案放在 tests/login.spec.ts,目標站台是 https://the-internet.herokuapp.com,共三個場景:
1) 正確帳密(tomsmith / SuperSecretPassword!)→ 導向 /secure,頁面出現 Secure Area 標題,以及成功訊息 You logged into a secure area!。
2) 密碼打錯 → 停留在 /login,出現錯誤訊息 Your password is invalid!。
3) 帳號打錯 → 停留在 /login,出現錯誤訊息 Your username is invalid!。
定位器優先用 getByRole、getByLabel,不要用 CSS class。
這個站的表單有正規的 label,所以 getByLabel('Username')、getByLabel('Password') 直接可用,送出鈕用 getByRole('button', { name: 'Login' }) 抓得到。這是刻意挑的:定位器好寫的站,你才有心力想別的事情。
留意場景 2 和場景 3 的差別。這個站帳號打錯講一句話、密碼打錯講另一句話,等於直接告訴你「這組帳號存在,只是密碼不對」。攻擊者拿這個訊息差異可以先把有效帳號撈一遍,再專心猜密碼。
所以這裡有兩層練習:先照著寫出通過的測試,再回頭看你們自家系統的登入訊息是不是也這樣分。真實系統的正解是兩種情況都回同一句「帳號或密碼錯誤」。這種檢查點,就是測試人員比 AI 多想一步的地方。
驗收點:三個測試都跑綠,而且你能說出每個測試各驗了幾件事。
如果 Claude Code 只驗了「有沒有轉到 /secure」,回頭要求它把訊息內容也驗進去。
二、驗證碼和簡訊 OTP 怎麼辦
很多人學到這裡會卡住:「我們的登入有圖形驗證碼」「要收簡訊 OTP」。這題必須正面回答,因為它擋住的是整條自動化的路。
先講原則:驗證碼存在的目的就是擋住機器人。不要想用自動化去破解它,那條路技術上難,方向上也錯——你破解得了,等於你們的驗證碼白裝了。
正路是讓測試環境不需要過這一關,由開發者開一道明確的門。常見三種做法:

圖 1:驗證碼與 OTP 的處理方式
這在業界是標準做法嗎
先講一件容易誤會的事:Playwright 官方文件沒有驗證碼這一章。你去翻它的文件,找不到「怎麼處理 CAPTCHA」的段落。
這不是漏寫。驗證碼過不過得去,取決於應用程式怎麼設定,測試框架管不到這件事。Playwright 官方文件負責的範圍,是你登入之後怎麼重用狀態,也就是下一節要做的 storageState。
有明確立場的是 Selenium。它的官方文件把驗證碼放在「不建議自動化」的分類底下,直說不要嘗試,然後給兩條路:在測試環境把驗證碼關掉,或是加一個讓測試繞過的掛鉤。這篇文章第一張圖的方向,就是照這個立場來的。
至於怎麼繞,答案在服務提供者那邊,而且是他們自己準備好的功能:
● Google reCAPTCHA 公開一組測試專用的 site key 與 secret key,套上去之後驗證一律通過,畫面會出現這是測試用途的警告字樣,避免被拿去正式環境用。
● Firebase Authentication / Google Identity Platform 提供測試門號功能:登記一組虛構門號和固定的六位數驗證碼,登入時不會真的發簡訊,輸入登記好的碼就能過。官方同時提醒,那組門號和真門號一樣有效,要存放妥當並定期更換。Supabase 後來也在後台加了同樣的設定。
Playwright 這邊唯一沾到邊的,是它給 AI agent 用的 CLI 文件:那頁寫著,agent 卡在 CAPTCHA 或 2FA 的時候,由人接手處理。換句話說,官方的立場是這一關過不去就是過不去,由人來過。
另外有一個側面訊號值得知道。Playwright 的 GitHub 上有一則功能請求,希望框架執行測試時自動設一個環境變數,好讓應用程式據此關掉 reCAPTCHA、換掉第三方整合。提案的理由是這件事現在要在好幾個地方手動接線、很容易漏掉——會這樣提,代表「用環境變數在測試時關掉驗證碼」本來就是大家在做的事。這是社群訊號,還沒進官方文件,拿去說服開發者的時候要說清楚差別。
整理一下:方向有 Selenium 官方背書,做法有 Google 和 Firebase 的現成機制,Playwright 則是不管這一段。你要跟開發者要的東西,不是什麼特別的通融。開口的說法可以很簡單:
你可以這樣跟開發者開口
自動化測試需要在測試環境繞過驗證碼。
你們方便用測試金鑰、白名單帳號,還是開一個測試專用的登入入口?
談成之後,補一個安全確認:這個繞過機制,正式環境進得去嗎?答案必須是進不去。順口問這一句,你可能就擋下一個資安事件。這種警覺,本來就是測試人員的價值。
三、真正的重點:其他測試不該重複登入
想像你有五十個測試,每個開頭都要登入。一輪多花好幾分鐘,而且只要登入流程稍有不穩,五十個測試全部陪葬——它們要測的明明是購物車和訂單。
圖 2:每個測試重新登入,與登入一次、狀態重用的差別
解法是 Playwright 的 storageState 機制:所有測試開始前登入一次,把登入後的狀態(cookies、瀏覽器儲存)存成一個檔案。之後每個測試啟動時直接載入,一睜眼就是已登入。
這也是 Playwright 官方 Authentication 文件的第一個建議做法,官方用的詞是「shared account in all tests」,適用於測試之間不會互相改到伺服器資料的情況。如果你們的測試會互相改到同一份資料(一個測試改設定、另一個測試在讀設定),官方另有一套每個並行 worker 配一組帳號的做法,等你跑到那個規模再處理。
四、動手做一次:storageState 從零到跑通
這一節是完整的操作流程,從一個空專案開始,一路做到「所有測試都自動帶登入狀態」。用的是第一節那個練習站,所以每一步你都可以真的跑起來看結果。
整套設定會動到三個檔案,先看清楚它們各自負責什麼:
圖 3:storageState 登入重用的檔案分工
然後是執行時實際的順序,後面每一步在做的事情都能對回這張圖:

圖 4:一次 npx playwright test 的執行順序
步驟 0:開一個新專案
npm init playwright@latest storage-demo
cd storage-demo
安裝過程問到的選項照預設走就好。裝完把它產生的範例測試 tests/example.spec.ts 刪掉,我們要自己寫。
步驟 1:先寫兩個各自登入的測試,感受一下問題
不先痛一次,後面的設定會變成無感的儀式。請 Claude Code 寫兩個都需要登入的測試:
你可以這樣對 Claude Code 說
站台是 https://the-internet.herokuapp.com。請寫兩個測試檔:
1) tests/secure-area.spec.ts:登入(tomsmith / SuperSecretPassword!)後,驗證 /secure 頁面出現 Secure Area 標題。
2) tests/logout.spec.ts:登入後點 Logout,驗證回到登入頁並出現 You logged out of the secure area!。
兩個檔案各自在測試開頭做完整的登入流程。
跑一次,把秒數記下來:
npx playwright test
注意兩個檔案開頭那五、六行登入程式碼是一模一樣的。只有兩個測試還好,五十個就是災難。
步驟 2:準備存放位置與 .gitignore
官方建議把狀態檔放在 playwright/.auth/ 底下,並且立刻加進 .gitignore。這一步先做,後面才不會不小心把登入憑證推上去。
mkdir -p playwright/.auth
echo "playwright/.auth" >> .gitignore
echo ".env" >> .gitignore
確認結果:打開 .gitignore,最後兩行應該是 playwright/.auth 和 .env。
步驟 3:把帳密搬到 .env
這個練習站的帳密是公開的,搬不搬其實無所謂。但這一步要練,因為換成你們自家系統之後,它是不能省的。
npm install -D dotenv
.env(不進版本控制):
BASE_URL=https://the-internet.herokuapp.com
TEST_USER=tomsmith
TEST_PASSWORD=SuperSecretPassword!
.env.example(要進版本控制,只寫欄位名稱,讓下一個接手的人知道要準備什麼):
BASE_URL=
TEST_USER=
TEST_PASSWORD=
確認結果:執行 git status,.env 不應該出現在裡面,.env.example 應該出現。
步驟 4:建立 tests/auth.setup.ts
這個檔案是整輪測試裡唯一會輸入帳密的地方。
你可以這樣對 Claude Code 說
請建立 tests/auth.setup.ts:
1) 用 @playwright/test 的 test as setup。
2) 打開 /login,用 getByLabel 填入 process.env.TEST_USER 和 process.env.TEST_PASSWORD,按 Login。
3) 用 waitForURL 等到 /secure,再用 expect 確認 Secure Area 標題出現,確保 cookie 真的拿到了。
4) 最後把狀態存到 playwright/.auth/user.json。
它產生的檔案應該接近這樣,可以直接拿來對照:
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Login' }).click();
// 等到真的登入完成,cookie 才會齊
await page.waitForURL('**/secure');
await expect(
page.getByRole('heading', { name: 'Secure Area' })
).toBeVisible();
await page.context().storageState({ path: authFile });
});
中間那兩行等待是關鍵,不能省。
登入常常經過好幾次轉址才把 cookie 設完,點完送出鈕就馬上存檔,存到的會是一個還沒登入的空狀態。官方文件特別把這件事寫出來,就是因為太多人踩到。
步驟 5:修改 playwright.config.ts
你可以這樣對 Claude Code 說
請修改 playwright.config.ts:
1) 最上面載入 dotenv。
2) baseURL 從環境變數 BASE_URL 讀。
3) 加一個名為 setup 的專案,用 testMatch 抓 *.setup.ts。
4) chromium 專案加上 dependencies: ['setup'],並在 use 裡設定 storageState 指向 playwright/.auth/user.json。
改完告訴我你動了哪幾行。
改完的骨架長這樣:
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
use: { baseURL: process.env.BASE_URL },
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
三個地方值得看一眼:setup 專案靠檔名比對抓到 auth.setup.ts;dependencies 保證它先跑完;storageState 指到剛才存下來的檔案。這三個少一個,整套就不會動。
步驟 6:把兩個測試裡的登入步驟刪掉
現在那些登入程式碼是多餘的,而且會害測試失敗——已經登入的狀態下再打開登入頁,行為跟你原本預期的不一樣。
你可以這樣對 Claude Code 說
請把 tests/secure-area.spec.ts 和 tests/logout.spec.ts 裡面,
測試開頭重複的登入步驟拿掉,改成直接 goto('/secure')。
其他驗證邏輯不要動。
兩個檔案會各短一截,而且只剩下它真正要測的東西。
步驟 7:讓登入功能的測試從乾淨狀態開始
第一節寫的那三個登入測試要從沒登入的狀態開始,所以要單獨排除。在 tests/login.spec.ts 最上方,import 後面加一行:
import { test, expect } from '@playwright/test';
// 這個檔案的測試不要沿用專案層級的登入狀態
test.use({ storageState: { cookies: [], origins: [] } });
這是官方文件給的寫法,意思是這一整個檔案的測試都從空白狀態開始。少了這一行,你的「密碼打錯」測試會因為已經登入而整個跑歪。
步驟 8:跑起來,看報告
npx playwright test
成功的話,終端機會像這樣——注意第一行的 setup,整輪就出現這一次:
Running 6 tests using 4 workers
ok 1 [setup] > auth.setup.ts:5:1 > authenticate (2.1s)
ok 2 [chromium] > secure-area.spec.ts:3:1 > 進入安全區 (0.8s)
ok 3 [chromium] > logout.spec.ts:3:1 > 登出 (1.0s)
ok 4 [chromium] > login.spec.ts:5:1 > 正確帳密 (1.3s)
...
6 passed (8.4s)
跟步驟 1 記下來的秒數比一比。測試越多,差距越明顯。
步驟 9:打開 user.json 看一眼
這一步不是必要的,但看過一次之後,你對「登入狀態」這件事的理解會完全不一樣。打開playwright/.auth/user.json:
{
"cookies": [
{ "name": "rack.session",
"value": "BAh7CEkiD3Nlc3Npb25faWQGOgZFVEkiRTk...",
"domain": "the-internet.herokuapp.com",
"path": "/", "expires": -1, "httpOnly": true }
],
"origins": []
}
這個站的登入狀態就是 rack.session 這一個 cookie。origins 是空的,因為它沒有用到 localStorage——你們自家系統如果用 token,那一段就會有東西。
也因為長這樣,任何人拿到這個檔案都能直接冒用登入身分,這就是它必須進 .gitignore 的原因。
步驟 10:寫一個會抓包的驗收測試
設定類的改動最容易「看起來有做、其實沒生效」,所以要有一個做錯就會紅的測試:
你可以這樣對 Claude Code 說
幫我寫 tests/auth-check.spec.ts:
直接 goto('/secure'),中間完全不經過登入頁,
驗證頁面出現 Secure Area 標題。
這個練習站的行為讓這個驗收特別有效:沒登入直接開 /secure,它會把你踢回 /login 並顯示「You must login to view the secure area!」。所以 storageState 沒生效的時候,這個測試一定紅,不會給你假的安心。
多做一步:主動製造一次失敗。
把 config 裡 storageState 那一行暫時註解掉,重跑這個測試,它應該要紅。看到它紅,你才確定這個測試真的有在驗東西。確認完再把那行改回來。
換成你們自己的系統
練習站跑通之後,整套搬到自家系統只要換掉幾個地方:


最後一列是順序問題:驗證碼那關沒談好,auth.setup.ts 就登入不進去,後面全部卡住。
卡住了怎麼查

這些狀況大多在 Playwright 官方 Authentication 文件裡有對應段落,遇到的時候不是你做錯什麼特別的事,只是踩到大家都會踩的那幾個。
五、帳密放哪裡:一條不能踩的線
測試帳密不要寫死在程式裡。程式碼會被分享、上傳、貼給 AI、進版本控制,密碼會跟著旅行到你想不到的地方。
圖 5:測試帳密存放的三個等級
學習階段的正解:放在 .env 檔案,一個不進版本控制的本機設定檔,程式透過環境變數讀取。同時附一份 .env.example 只寫欄位名稱、不寫值,讓下一個接手的人知道要準備哪些變數。
請 Claude Code 設定時它通常會一起處理好,但你可以抽查:
你可以這樣對 Claude Code 說
確認一下:.env 和 playwright/.auth 有沒有都加進 .gitignore?
測試程式和設定檔裡,還有沒有任何寫死的帳號密碼?
這裡要特別提醒 storageState 存下來的那個 user.json。Playwright 官方文件用了很重的字眼警告:那個檔案裡是有效的 cookie 和標頭,拿到的人可以直接冒用你的測試帳號,所以不要進版本控制,不管是公開還是私有的倉庫。
之後上 CI還有更正式的祕密管理機制,把值加密存起來、日誌自動遮蔽。現階段先把「密碼不進程式碼」這條線守住就好。
今天的練習
完成檢查清單
檢查項目
□ 登入的三個場景都有測試,而且驗到了錯誤訊息的內容
□ 測試環境的驗證碼繞過方式已經談定,正式環境確認進不去
□ playwright/.auth/user.json 有產生,內容不是空的
□ 整輪測試只登入一次,報告裡的 setup 只出現一筆
□ 驗收測試在拿掉 storageState 之後會變紅
□ 登入功能的測試檔案有加上 storageState 清空那一行
□ .env 與 playwright/.auth 都在 .gitignore,git status 看不到它們
□ 測試程式裡沒有任何寫死的帳號密碼